Authentication Overview
The Patient Portal Authentication API lets a partner application sign a patient in to the Care Validate Patient Portal using a one-time passcode (OTP) delivered over email or SMS. After verification, the API issues a short-lived access token (JWT) and a longer-lived refresh token that can be rotated without requiring the patient to enter another OTP.
Endpoints
| # | Method | Path | Purpose |
|---|---|---|---|
| 1 | POST | /api/v1/users/auth/send-otp | Request a one-time code via Email or SMS |
| 2 | POST | /api/v1/users/auth/verify-otp | Exchange a valid OTP for an access + refresh token pair |
| 3 | POST | /api/v1/users/auth/refresh-token | Rotate the refresh token and obtain a new access token |
| 4 | POST | /api/v1/users/auth/logout | Revoke the entire refresh-token family |
Common Headers
| Header | Required | Description |
|---|---|---|
cv-api-key | Yes (all endpoints) | Tenant API key. Resolves the calling organization. Missing → 400. Unknown / non-partner → 404. |
Content-Type | Yes (all endpoints) | Must be application/json. |
Authorization | No | None of the four authentication endpoints take a Bearer header. The /refresh-token and /logout endpoints authenticate via the refresh token in the request body. |
The organization referenced by cv-api-key must be configured as a partner. Otherwise the response is 404 NOT_FOUND with body { status: 404, success: false, error: 'Organization not found', code: 'NOT_FOUND' }.
Common Response Envelope
All success responses follow:
{ "status": 200, "success": true, "...": "endpoint-specific payload" }
All error responses follow:
{ "status": 400, "success": false, "error": "<message>", "code": "<CODE>" }
Token-issuing responses (/verify-otp, /refresh-token) additionally set:
Cache-Control: no-store
End-to-End Flow
Token Model
Access token (JWT)
| Property | Value |
|---|---|
| Algorithm | HS512 (pinned) |
| Issuer | ada-backend |
| Expiry | 15 minutes (expiresIn: 900) |
| Required claim | type: "patient-portal" — tokens without it are rejected on Patient Portal endpoints |
| Cross-tenant guard | organizationId claim is checked against the cv-api-key org on every authenticated call |
Payload claims:
{
"userId": "<uuid>",
"organizationId": "<uuid>",
"type": "patient-portal",
"role": "<role>",
"organizationAccessRole": "<access-role>"
}
Refresh token
| Property | Value |
|---|---|
| Format | Opaque base64url string (32 random bytes) |
| Storage on server | SHA3-512 base64 hash only — plaintext never persisted |
| Sliding expiry | 30 days (resets on each rotation) |
| Absolute expiry | 90 days (from family creation) |
| Family | Each rotation stays in the same familyId; replay of a rotated token revokes the entire family |
Refresh Token Lifecycle
Security Properties
- No user enumeration.
send-otpreturns the same response regardless of whether the user exists.verify-otpcollapses "no user", "no active OTP", "wrong code", and "already used" into a single401. - OTP brute-force ceiling. OTP is 6 digits (10⁶ keyspace), capped by
maxAttempts, with a 5-minute TTL, single-use, and the row is deleted on success. - Atomic OTP claim. Verification uses a single conditional
UPDATE ... RETURNINGto claim an attempt — no read-then-increment race. - JWT replay isolation. The
type: "patient-portal"claim prevents tokens issued for other surfaces from being used here. - Cross-tenant replay isolation. The JWT's
organizationIdclaim is cross-checked againstcv-api-keyon every call. - Algorithm pinned.
jwt.verifyis called withalgorithms: ['HS512']to prevent algorithm-downgrade attacks. - Refresh token storage. Only the SHA3-512 hash is persisted.
- Refresh rotation. Every successful
/refresh-tokencall invalidates the presented token. Replay of a rotated token revokes the entire family. - Family revocation on logout. Logout revokes by
familyId— all currently-active siblings are simultaneously revoked. - No-store on token responses.
Cache-Control: no-storeis set on every token-issuing response.
Integrator Guidance
cv-api-keyis a server-side secret. Treat it like any other API key and keep it on a server when possible.- Token storage. Treat both tokens as credentials. Store the access JWT in memory; store the refresh token in platform-secure storage (iOS Keychain, Android Keystore,
HttpOnly+Secure+SameSite=Strictcookie on web). Never uselocalStorage. - Proactive rotation. Rotate the refresh token before the 15-minute access-token expiry; do not wait for a
401. REFRESH_REUSEDis terminal. Wipe local tokens and require the user to start over with/send-otp. Do not retry.- Channel and identifier must agree.
channel: "SMS"requiresphoneNumber.channel: "EMAIL"requiresemail. - Phone format. E.164 (e.g.
+15551234567). send-otpsuccess ≠ user exists. A200is returned even when no user matches.- Generic
verify-otpfailures. Do not surface specific OTP-failure reasons to end users beyond what the API returns. - Do not reuse tokens across organizations. The
organizationIdclaim is enforced on every call.
Constants Reference
| Constant | Value |
|---|---|
| Access-token TTL | 900 seconds (15 minutes) |
| Refresh-token sliding TTL | 30 days |
| Refresh-token absolute TTL | 90 days |
| OTP digits | 6 |
| OTP TTL | 5 minutes |
| OTP max attempts | 3 |
| OTP code regex | ^\d{6}$ |
| OTP hash algorithm | SHA3-512 → base64 |
| Refresh-token raw size | 32 random bytes (base64url) |
| Refresh-token hash algorithm | SHA3-512 → base64 |
| JWT algorithm | HS512 (pinned) |
| JWT issuer | ada-backend |
| JWT type claim | patient-portal |
Versioning
These endpoints are part of v1 of the Patient Portal API (/api/v1). Breaking changes will be released under a new /api/vN path. Additive, backwards-compatible changes (new optional fields, new error subtypes within existing code taxonomies) may occur within v1 without a path bump.